feat(workspace): logo upload + brand colors in workspace settings#941
feat(workspace): logo upload + brand colors in workspace settings#941mdubel wants to merge 5 commits into
Conversation
Organization workspaces can now set a brand identity from Workspace settings: an uploadable logo image and a main + auxiliary brand color. - Logo: stored inline as a size-capped base64 data URL on the namespace record (no new upload/serving route — it travels with the existing authenticated namespaces.get / users.me payloads and renders via a plain <img>). Replaces the Lucide icon in the sidebar switcher, workspace header, and workspace picker; falls back to the icon when unset. - Colors: #rrggbb hex on the namespace record, converted to HSL triples and injected as a <style> that overrides the --primary / --accent design tokens app-wide (plus --ring / --sidebar-* mirrors, with a contrast-picked foreground), applied in both light and dark. All three fields flow through the existing PATCH /api/namespaces/:handle (empty string clears). The base64 logo is kept out of the audit snapshot. Covers schema -> Postgres (migration 0029) + in-memory double -> contract -> handler -> client/hook -> settings UI + every display site, plus MeNamespace so the sidebar sees it. Tests: contract, handler, repo parity, and a hex->HSL unit test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
There was a problem hiding this comment.
Pull request overview
Adds workspace branding (organization workspaces) by extending the existing namespace/workspace record with an inline logo (data: URL) plus primary/accent brand colors, and wiring that through the API, repositories, and UI so the logo/colors render consistently across workspace surfaces.
Changes:
- Extend workspace/namespace schema + persistence (Postgres + repo) to store
logo,brandPrimaryColor, andbrandAccentColor, including a migration. - Update API contracts/handlers and UI mutation hooks to accept/patch/echo the new fields (and keep the audit snapshot small for logos).
- Add UI rendering + settings controls: logo upload/removal, color pickers, and runtime token overrides via
BrandTheme.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/platform-ui/src/lib/brand-color.ts | Pure helpers to convert #rrggbb to HSL triples and choose readable foregrounds. |
| packages/platform-ui/src/lib/tests/brand-color.test.ts | Unit tests for hex→HSL conversion and foreground selection. |
| packages/platform-ui/src/hooks/use-namespace-mutations.ts | Optimistic updates + cache updates for logo and brand colors. |
| packages/platform-ui/src/components/brand-theme.tsx | Client-side token override injector for --primary/--accent (+ mirrors). |
| packages/platform-ui/src/components/app-shell.tsx | Apply BrandTheme and render logos in the namespace switcher. |
| packages/platform-ui/src/app/workspace-selection/page.tsx | Render org logo in workspace selection cards when present. |
| packages/platform-ui/src/app/(app)/[handle]/settings/page.tsx | Add branding section (logo upload + color pickers) in workspace settings. |
| packages/platform-ui/src/app/(app)/[handle]/page.tsx | Render workspace logos in profile/workspace listings and header. |
| packages/platform-infra/src/postgres/schema/workspace.ts | Add new columns to Drizzle workspace table schema. |
| packages/platform-infra/src/postgres/repositories/namespace-repository.ts | Map new fields to/from DB rows and update mutations. |
| packages/platform-infra/src/postgres/migrations/meta/_journal.json | Register migration 0029_workspace_branding. |
| packages/platform-infra/src/postgres/migrations/0029_workspace_branding.sql | Add logo, brand_primary_color, brand_accent_color columns. |
| packages/platform-infra/src/postgres/tests/namespace-parity.test.ts | Ensure repo parity/round-trip includes branding fields. |
| packages/platform-core/src/schemas/namespace.ts | Add Zod schemas for logo + brand colors and extend NamespaceSchema. |
| packages/platform-core/src/schemas/index.ts | Re-export branding schemas/constants. |
| packages/platform-core/src/interfaces/namespace-repository.ts | Extend NamespaceUpdates to include branding fields. |
| packages/platform-core/src/index.ts | Re-export branding schemas/constants at package root. |
| packages/platform-api/src/handlers/users/get-me.ts | Include branding fields in users.me namespace list output. |
| packages/platform-api/src/handlers/namespaces/namespace-mutations.ts | Accept branding updates; omit base64 logo from audit snapshot. |
| packages/platform-api/src/handlers/namespaces/tests/namespace-mutations.test.ts | Tests for updating/clearing branding and audit snapshot behavior. |
| packages/platform-api/src/contract/users.ts | Extend MeNamespaceSchema to include branding fields. |
| packages/platform-api/src/contract/namespaces.ts | Extend update schema to accept logo + brand color fields. |
| packages/platform-api/src/contract/tests/namespaces.test.ts | Contract tests for accepting/rejecting branding inputs. |
| CHANGELOG.md | Add Unreleased entry documenting workspace branding feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * A workspace logo, stored inline as a base64 `data:` image URL (or `""` to | ||
| * clear). Kept on the workspace record rather than the blob store so it travels | ||
| * with the already-authenticated `namespaces.get` / `users.me` payloads and | ||
| * renders via a plain `<img src>` — no separate authenticated fetch. The | ||
| * ~256 KiB char cap keeps that payload small; logos should be optimised | ||
| * (SVG/PNG) assets, not photos. | ||
| */ |
| const dataUrl = await new Promise<string>((resolve, reject) => { | ||
| const reader = new FileReader(); | ||
| reader.onload = () => resolve(String(reader.result)); | ||
| reader.onerror = () => reject(reader.error); | ||
| reader.readAsDataURL(file); | ||
| }); | ||
| try { | ||
| await updateNamespace.mutateAsync({ handle, logo: dataUrl }); | ||
| } catch { | ||
| setLogoError('Failed to upload logo.'); | ||
| } |
| const block = declarations.join(';'); | ||
| const css = `:root{${block}}\n.dark{${block}}`; | ||
|
|
||
| return <style dangerouslySetInnerHTML={{ __html: css }} />; |
| avatarUrl: z.string().url().optional(), | ||
| icon: z.string().optional(), | ||
| logo: z.string().optional(), | ||
| brandPrimaryColor: z.string().optional(), | ||
| brandAccentColor: z.string().optional(), |
# Conflicts: # packages/platform-infra/src/postgres/migrations/meta/_journal.json
Self-review found three blockers in the branding PR. Dark mode: BrandTheme emitted one identical declaration block for :root and .dark, so `--accent` — the `hover:bg-accent` surface, a near-black `217.2 32.6% 17.5%` in dark — was replaced by a fully saturated brand color, and `--primary` lost the lightness lift its dark default applies. The two modes now derive separately: light passes the brand color through (its `--accent` default is already a saturated amber), dark lifts `--primary` to >=55% lightness and clamps `--accent` into the band its default occupies. `brandTokenTriples()` derives the foreground from the adjusted color, so a clamped hover surface gets legible text. The `--sidebar-accent*` overrides are dropped — zero usages. Payload: `users.me` carries one logo per membership, so a 512 KiB cap meant multi-MB responses on every app-shell mount. Rather than shrink what users may upload, uploads are now downscaled to 256px on the longest edge before encoding (SVGs pass through), so stored logos land in the single-digit KB range and the cap is a backstop. Coverage: adds the L3 API journey the feature was missing — round-trip, clearing, size cap, malformed hex, and unauthenticated writes — plus unit tests for the token derivation. MeNamespace fields now use the WorkspaceLogoSchema / BrandColorSchema this PR already exports instead of bare z.string(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-review follow-up — three blockers fixed (9a3ec7d)Ran 1. Brand tokens were flattened across light and dark — regression
Worth noting for reviewers: this was only broken in dark mode. Light mode's
New Also dropped the 2.
|
Closes the known gap on #941: fileToLogoDataUrl had no coverage because it leans on canvas / Image, neither of which jsdom implements. Stubs Image decoding and the 2d context; FileReader is jsdom's real one, so the data: URL read out of the File is genuine. Covers SVG passthrough, downscale geometry in both orientations, the never-upscale guard, both size caps, an undecodable file, and the no-context fallback. Verified non-vacuous by mutation: dropping the upscale guard, the SVG passthrough, or the post-encode cap each fails the suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Known gap closed —
|
| Mutation | Failures |
|---|---|
| drop the never-upscale guard | 1 |
| remove SVG passthrough | 2 |
| remove the post-encode size cap | 2 |
pnpm typecheck clean; src/lib suite 241/241 green.
What this still doesn't prove: that the bytes are a correct image. drawImage/toDataURL are stubs, so the test pins the decision logic — which dimensions, which branch, which error — not pixel output. Real rasterisation needs the canvas native dep or an L4 journey with a fixture PNG; both felt disproportionate for a 79-line module whose risk is branch selection. Say the word if you'd rather have the L4.
Spec correction — PR description is staleTwo numbers in the description above no longer match the branch; noting them here rather than rewriting the description so the original intent stays visible.
Neither is a behaviour change — the description was written against an earlier revision. The 512 KiB cap is a backstop, not the mechanism that keeps payloads small: rasters are downscaled to Also landed since the description was written:
Test-fixture note worth flagging for reviewers: 🤖 Generated with Claude Code |
Follow-ups from review of the branding PR. Enforce org-only branding in `updateNamespace` rather than only hiding it in the settings UI: a personal namespace renders its linked user's avatar, so `icon` / `logo` / brand colors have nowhere to show and are rejected with `409 precondition_failed` naming the offending fields. This narrows the public surface — `mediforce namespace update --handle <personal> --icon Foo` previously succeeded and wrote a field nothing rendered. Accepted deliberately; no in-repo caller or user flow hits it (`handleSaveProfile` sends only displayName/bio, the icon picker is already org-gated). Collapse five hand-rolled logo-or-icon renders into one `WorkspaceAvatar`. Four of them had no image-error path, so a corrupt stored logo showed a broken-image glyph; all five now fall back to the workspace icon. The component keys its error state on the failing source rather than a boolean, so a re-upload retries instead of latching. Add `--logo` / `--brand-primary-color` / `--brand-accent-color` to `mediforce namespace update`, closing the dogfood gap where three handler-supported fields were UI-only. `--logo` takes a file path (or a `data:` URL). The CLI has no browser canvas, so it does not downscale — an oversized source is rejected by the cap rather than silently resized. Also: derive brand-preview swatch text via `readableForegroundTriple` instead of hardcoded `text-white`; make implicit-truthy checks explicit; guard both modes in `BrandTheme`; document that animated GIFs flatten to their first frame (the PNG re-encode carries no animation). Test-fixture fix: `TEST_ORG_HANDLE` is seeded `type: 'personal'` despite the name, so the branding journeys were exercising the personal path and never covered the organization path they claimed to — the org-only rule surfaced this. The journey now seeds its own org per test (a shared handle raced its own afterEach clear under parallel workers); `constants.ts` documents the trap. Renaming the constant (147 usages / 44 files) is left out as too large for this PR. Verified: platform-ui + cli tsc clean, 52/52 handler, 471/471 UI unit, 286/286 CLI, 70/70 API E2E. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What
Gives organization workspaces a brand identity, configurable from Workspace settings → Profile → Branding (owners/admins only):
--primary(buttons, links, active nav, focus rings), the auxiliary drives--accent, recoloring the UI in both light and dark mode with an auto-contrast foreground.How
Both ride the existing
PATCH /api/namespaces/:handlepath — no new routes. Empty string clears any field.data:URL on the workspace record, so it travels with the already-authenticatednamespaces.get/users.mepayloads and renders via a plain<img src>— no auth-in-<img>problem, no blob-store serving route, no proxy carve-out. The base64 blob is stripped from the audit snapshot.#rrggbbhex on the same record. A small client component (brand-theme.tsx) converts them to HSL triples (brand-color.ts) and injects a<style>overriding--primary/--accent(+--ring/--sidebar-*mirrors). It emits both:root{}and.dark{}so the override wins over next-themes'.darktoken block.Full chain: schema → Postgres (migration
0029) + in-memory double → contract → handler → client/hook → settings UI + every display site →MeNamespaceso the sidebar sees it.Tests
pnpm typecheckclean; new/extended tests green:TEST_DATABASE_URL).hexToHslTriple/ contrast picker.Existing workspaces (no logo/colors) render byte-identically —
BrandThemereturnsnulland every logo site falls back to the current icon path.Notes for reviewers
db:migrate— migration0029addslogo,brand_primary_color,brand_accent_colortoworkspaces(all nullable).--accent, which shadcn also uses for subtle hover surfaces (hover:bg-accent) — so a very saturated auxiliary color tints hover states app-wide, especially in dark mode. This matches the "colors used everywhere" intent; happy to scope it to badges/highlights only if preferred (one-line change to which tokens it overrides).🤖 Generated with Claude Code